Will Xiao

分享是最好的学习

命令模式实现 undo & redo

前端 undo & redo 功能是非常常见的,通常会使用命令模式来实现。

下面以一个低代码编辑器的例子,来介绍 JavaScript 是如何使用命令模式来实现 undo & redo 功能的。

首先,我们来看一下命令模式的结构示意图。

alt text

在命令模式中,关键是定义了一个 Command 接口,它有 execute 和 undo 两个方法,具体的命令类都需要实现这两个方法。调用者(Invoker)在调用命令的时候,只需要执行命令对象的 execute 和 undo 方法即可,而不用关心这两个方法具体做了什么。实际上这两方法的具体实现,通常都是在接收者(Receiver)中,命令类中通常有一个接收者实例,命令类只需要调用接收者实例方法即可。

OK,我们来看一下,我们的低代码编辑器的状态库(简化版的)。它是使用 zustand 定义的,它有一个组件列表 componentList,以及相关的3个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { createStore } from "zustand/vanilla";

const store = createStore((set) => ({
componentList: [], // 组件列表
// 添加组件
addComponent: (comp) =>
set((state) => ({ componentList: [...state.componentList, comp] })),
// 删除组件
removeComponent: (comp) =>
set((state) => ({
componentList: state.componentList.filter((v) => v.id !== comp.id),
})),
// 更新组件属性
updateComponentProps: (comp, newProps) =>
set((state) => {
const index = state.componentList.findIndex((v) => v.id === comp.id);
if (index > -1) {
const list = [...state.componentList];
return {
componentList: [
...list.slice(0, index),
{ ...comp, props: newProps },
...list.slice(index + 1),
],
};
}
}),
}));
// const { getState, setState, subscribe, getInitialState } = store;

export default store;

接下来,我们看一下相关命令类的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// 命令基类
class Command {
constructor() {}

execute() {
throw new Error("未重写 execute 方法!");
}

undo() {
throw new Error("未重写 undo 方法!");
}
}

export class AddComponentCommand extends Command {
editorStore; // 状态库(它充当 Receiver)
comp;

constructor(editorStore, comp) {
super();
this.editorStore = editorStore;
this.comp = comp;
}

execute(comp) {
this.editorStore.getState().addComponent(this.comp);
}

undo() {
this.editorStore.getState().removeComponent(this.comp);
}
}

export class RemoveComponentCommand extends Command {
editorStore;
comp;

constructor(editorStore, comp) {
super();
this.editorStore = editorStore;
this.comp = comp;
}

execute() {
this.editorStore.getState().removeComponent(this.comp);
}

undo() {
this.editorStore.getState().addComponent(this.comp);
}
}

export class UpdateComponentPropsCommand extends Command {
editorStore;
comp;
newProps;
prevProps; // 保存之前的属性

constructor(editorStore, comp, newProps) {
super();
this.editorStore = editorStore;
this.comp = comp;
this.newProps = newProps;
}

execute() {
const { updateComponentProps, componentList } = this.editorStore.getState();
this.prevProps = componentList.find((v) => v.id === this.comp.id)?.props;
updateComponentProps(this.comp, this.newProps);
}

undo() {
const { updateComponentProps } = this.editorStore.getState();
updateComponentProps(this.comp, this.prevProps);
}
}

我们实现了 AddComponentCommand、RemoveComponentCommand 和 UpdateComponentPropsCommand 3个命令类,在我们的命令类中都有一个 editorStore 属性,它在这里充当了 Receiver 接收者,因为编辑器相关操作我们都定义在状态库中。

其中 AddComponentCommand 和 RemoveComponentCommand 相对比较简单,有直接的操作可以实现撤销。UpdateComponentPropsCommand 就稍微复杂一点,我们更新了属性之后,没有一个直接的操作可以撤销修改,这种情况我们通常需要增加一个属性,记录修改之前的状态,用于实现撤销功能,在 UpdateComponentPropsCommand 中就是 prevProps。

到这里,我们的命令类都已经实现了,要实现 undo 和 redo 功能,通常我们还需要实现一个命令管理类,它需要实现 execute、undo 和 redo 三个方法。它的具体实现多种方法,我们这里使用两个栈(Stack)来实现,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class CommandManager {
undoStack = []; // 撤销栈
redoStack = []; // 重做栈

execute(command) {
command.execute();
this.undoStack.push(command);
this.redoStack = [];
}

undo() {
const command = this.undoStack.pop();
if (command) {
command.undo();
this.redoStack.push(command);
}
}

redo() {
const command = this.redoStack.pop();
if (command) {
command.execute();
this.undoStack.push(command);
}
}
}

export default new CommandManager();

有了这些,接下来我们可以进入测试环节了,下面是我们的测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import store from "./store/editorStore";
import cmdManager from "./commands/cmdManager";

// 实时打印组件列表
store.subscribe((state) =>
console.log(JSON.stringify(state.componentList))
);

const comp1 = {
id: 101,
componentName: "Comp1",
props: {},
children: null,
};
const comp2 = {
id: 102,
componentName: "Comp2",
props: {},
children: null,
};

cmdManager.execute(new AddComponentCommand(store, comp1));
cmdManager.execute(new AddComponentCommand(store, comp2));
cmdManager.undo();
cmdManager.redo();

cmdManager.execute(new RemoveComponentCommand(store, comp1));
cmdManager.undo();

cmdManager.execute(
new UpdateComponentPropsCommand(store, comp1, { visible: true })
);
cmdManager.undo();

测试结果如下,说明我们的代码正常工作了。

1
2
3
4
5
6
7
8
// [{"id":101,"componentName":"Comp1","props":{},"children":null}]
// [{"id":101,"componentName":"Comp1","props":{},"children":null},{"id":102,"componentName":"Comp2","props":{},"children":null}]
// [{"id":101,"componentName":"Comp1","props":{},"children":null}]
// [{"id":101,"componentName":"Comp1","props":{},"children":null},{"id":102,"componentName":"Comp2","props":{},"children":null}]
// [{"id":102,"componentName":"Comp2","props":{},"children":null}]
// [{"id":102,"componentName":"Comp2","props":{},"children":null},{"id":101,"componentName":"Comp1","props":{},"children":null}]
// [{"id":102,"componentName":"Comp2","props":{},"children":null},{"id":101,"componentName":"Comp1","props":{"visible":true},"children":null}]
// [{"id":102,"componentName":"Comp2","props":{},"children":null},{"id":101,"componentName":"Comp1","props":{},"children":null}]

至此,我们已经完成了完整的第一个版本了。但是代码还有优化的空间,我们继续改进一下。

第一点,执行命令的地方,要手动 new 命令类,传入 store 状态库,有较多的模板代码。

1
2
3
4
cmdManager.execute(new AddComponentCommand(store, comp1));
cmdManager.execute(new AddComponentCommand(store, comp2));
cmdManager.undo();
cmdManager.redo();

我们可以参考 js 原生方法 document.execCommand 实现一个 executeCommand () 方法,这样执行命令就变成了 executeCommand(commandName, …args) 这样,更为方便。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import cmdManager from "./cmdManager";
import {
AddComponentCommand,
RemoveComponentCommand,
UpdateComponentPropsCommand,
} from "./index";
import store from "../store/editorStore";

const commondActions = {
addComponent(...args) {
const cmd = new AddComponentCommand(store, ...args);
cmdManager.execute(cmd);
},

removeComponent(...args) {
const cmd = new RemoveComponentCommand(store, ...args);
cmdManager.execute(cmd);
},

updateComponentProps(...args) {
const cmd = new UpdateComponentPropsCommand(store, ...args);
cmdManager.execute(cmd);
},

undo() {
cmdManager.undo();
},

redo() {
cmdManager.redo();
},
};

const executeCommand = (cmdName, ...args) => {
commondActions[cmdName](...args);
};

export default executeCommand;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
store.subscribe((state) =>
console.log(JSON.stringify(state.componentList))
);

const comp1 = {
id: 101,
componentName: "Comp1",
props: {},
children: null,
};

const comp2 = {
id: 102,
componentName: "Comp2",
props: {},
children: null,
};

executeCommand("addComponent", comp1);
executeCommand("addComponent", comp2);
executeCommand("undo");
executeCommand("redo");

executeCommand("removeComponent", comp1);
executeCommand("undo");

executeCommand("updateComponentProps", comp1, { visible: true });
executeCommand("undo");

第二点,CommandManager 其实使用一个栈(Stack)加上指针也可以实现,我们参考了网上的代码(JavaScript command pattern for undo and redo),优化之后代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class CommandManager {
_commandsList = [];
_currentCommand = -1;

execute(command) {
command.execute();
this._currentCommand++;
this._commandsList[this._currentCommand] = command;
if (this._commandsList[this._currentCommand + 1]) {
this._commandsList.splice(this._currentCommand + 1);
}
}

undo() {
const command = this._commandsList[this._currentCommand];
if (command) {
command.undo();
this._currentCommand--;
}
}

redo() {
const command = this._commandsList[this._currentCommand + 1];
if (command) {
command.execute();
this._currentCommand++;
}
}
}

export default new CommandManager();

OK,这就是我们的第二个版本了。

参考资料:

《Head First 设计模式 - 命令模式》

javascript - 基于Web的svg编辑器(1)——撤销重做功能 - 个人文章 - SegmentFault 思否

JavaScript command pattern for undo and redo (s24.com)

Proudly powered by Hexo and Theme by Hacker
© 2024 Will Xiao